Make toStrictEqual and strict Bun.deepEquals distinguish null-prototype objects from object literals - #37776
Make toStrictEqual and strict Bun.deepEquals distinguish null-prototype objects from object literals#37776robobun wants to merge 5 commits into
Conversation
…pEquals
The strict "same type" check in Bun__deepEquals compares
JSObject::calculatedClassName, which reports "Object" both for an object
inheriting from Object.prototype and for one with no prototype at all, so
expect(Object.create(null)).toStrictEqual({}) and
Bun.deepEquals(Object.create(null), {}, true) passed. Jest and vitest fail
them: toStrictEqual's typeEquality compares constructors, and a
null-prototype object has none.
After the class names match, also reject the pair when exactly one side has
a null [[Prototype]]. Loose mode (toEqual, Bun.deepEquals without strict)
and the skipPrototype variants are unchanged; node:assert already compared
prototypes by identity.
|
Warning Review limit reached
Next review available in: 3 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (5)
Comment |
|
Reproduced on Bun 1.4.0 with CI for the current head (89c10d1): every job that ran passed (192/192). The build is marked failed only because the two Fix: #37776 (this PR), ready for review. |
…compared prototypes by identity
|
Updated 11:05 AM PT - Aug 12th, 2026
❌ @robobun, your commit 89c10d1 has some failures in 🧪 To try this PR locally: bunx bun-pr 37776That installs a local version of the PR into your bun-37776 --bun |
There was a problem hiding this comment.
I re-reviewed after 9c27643 and found no issues — the if constexpr (!checkPrototypes) guard addresses the earlier note about redundant getPrototype calls on the node entry point. Since this tightens what toStrictEqual / strict Bun.deepEquals accept for every user test suite, a human sign-off on the semantic change is still worthwhile.
Also checked: exception handling on both new getPrototype calls (RETURN_IF_EXCEPTION present, throwing-Proxy test covers both operand orders); loose mode, skipPrototypeIdentity, and the array fast path stay untouched; the un-marked it.failing plus new tests cover nesting, both operand orders, and two-null-proto equality.
Extended reasoning...
Overview
The PR adds a null-prototype check to Bun__deepEquals in src/jsc/bindings/bindings.cpp so that strict-mode Bun.deepEquals and expect().toStrictEqual() distinguish Object.create(null) / { __proto__: null } from ordinary object literals, matching jest and vitest. The change is ~12 lines of C++ plus comment updates, two doc additions, a converted it.failing test in deep-equals.test.ts with new Proxy/throwing-trap coverage, and a new toStrictEqual test block in expect.test.js.
Since my previous review, commit 9c27643 wrapped the new check in if constexpr (!checkPrototypes), so the node:assert/node:util entry point (which already compares prototypes by identity earlier in the function) no longer re-invokes getPrototype — that fully resolves the nit I raised.
Security risks
None. The change reads [[Prototype]] via the existing getPrototype(globalObject) helper (same call used a few lines above for the node path), with RETURN_IF_EXCEPTION after each call so a throwing Proxy trap propagates rather than being swallowed. No new allocation, no untrusted-size arithmetic, no user-controlled indexing.
Level of scrutiny
High. Bun__deepEquals backs every toEqual/toStrictEqual assertion in every bun:test suite and is on the hot path for object comparison. This is also a user-visible behavior change: tests that previously passed expect(Object.groupBy(...)).toStrictEqual({...}) will now fail (correctly, per jest/vitest). The implementation itself is small and straightforward, but the semantic decision — tightening a matcher that users depend on — warrants a maintainer's sign-off rather than an automated approval.
Other factors
- Tests are thorough: both operand orders, nested in objects and arrays, two null-proto objects still equal,
Object.groupBy, Proxy with and without a throwinggetPrototypeOftrap, andtoEqualstaying unchanged. The PR author verified against vitest 4.1.9 and ran the repo's othertoStrictEqualconsumers. - The two
comment-copbot flags on lines 819 and 962 look like heuristic false positives to me: line 819 is a pre-existing 3-line comment that this PR only reworded by two words, and line 962 is a 3-line note explaining whycalculatedClassNamealone is insufficient and why the node path is excluded — both explain non-obvious semantics rather than justify a workaround. A maintainer can decide whether to trim them. - No bugs were found by the bug-hunting system on this revision.
There was a problem hiding this comment.
LGTM — the earlier feedback (compiling the null-prototype check out on the checkPrototypes node path) is addressed, and the comment-cop notes were resolved by collapsing to one-liners.
What was reviewed:
- Traced all six
deepEqualsWrapperImplinstantiations: the new check runs only onstrictDeepEquals/jestStrictDeepEquals(isStrict && !skipPrototypeIdentity && !checkPrototypes); loose mode,toMatchObject, and both node entry points compile it out. getPrototype(globalObject)hasRETURN_IF_EXCEPTIONafter each call, and the throwing-trap test covers both operand orders.- Confirmed the array branch returns before this block, so arrays with null-prototype elements are only affected via recursion (as tested).
Extended reasoning...
Overview
Adds a null-prototype check to Bun__deepEquals<isStrict=true> in src/jsc/bindings/bindings.cpp so toStrictEqual and Bun.deepEquals(a, b, true) distinguish Object.create(null) from {}, matching jest/vitest. The fixing hunk is 10 lines: after the existing calculatedClassName comparison, read both [[Prototype]]s and return false when exactly one is null. Two comment lines were reworded, two docs pages gained one example each, an existing it.failing test was un-marked and extended, and new tests were added to expect.test.js and deep-equals.test.ts.
Security risks
None. This is a pure comparison-semantics change in the test runner / Bun.deepEquals utility. No parsing of untrusted input, no auth/crypto/permissions.
Level of scrutiny
Medium — a small native change in a heavily-templated equality function that backs multiple entry points. I traced all six deepEqualsWrapperImpl call sites to confirm the new block compiles into exactly the two intended instantiations (<true, false, false> and <true, true, false>): loose mode is gated out by isStrict, the node paths by !checkPrototypes (added after my earlier review), and the skip-proto variant by !skipPrototypeIdentity. Exception checks after each getPrototype are present, and the Proxy test with a throwing getPrototypeOf trap exercises them from both sides.
Other factors
My previous review flagged that the node entry point would invoke the Proxy getPrototypeOf trap twice; that was fixed in 9c27643 by wrapping the new lookups in if constexpr (!checkPrototypes), and I verified the current diff reflects that. The comment-cop bot's paragraph-comment complaints were addressed in fc00b84/6afda93. The PR description documents that the new tests fail on Bun 1.4.0 and pass with the change, and that test/js/node/assert/ and other toStrictEqual users in the repo were checked for regressions. Test coverage is thorough: both operand orders, nested in object properties and array elements, two null-prototype objects still equal, Object.groupBy, Proxies (default forwarding and throwing trap), and toEqual staying unchanged for every case.
There was a problem hiding this comment.
LGTM — the redundant getPrototype on the node path from my earlier review is addressed, and the comment-cop feedback is resolved.
What was reviewed:
- Traced all six
deepEqualsWrapperImplinstantiations against theisStrict && !skipPrototypeIdentity && !checkPrototypesguard: onlyBun.deepEquals(_, _, true)andtoStrictEqualreach the new code; the node and skipPrototype paths compile it out. - Checked exception handling — both new
getPrototypecalls haveRETURN_IF_EXCEPTIONand match the pattern at lines 822-825. - Tests cover both operand orders, nesting, arrays, two null-protos still equal, Proxy trap semantics, and a guard that
util.isDeepStrictEqual(_, _, true)stays prototype-blind.
Extended reasoning...
Overview
Adds a null-prototype vs Object.prototype distinction to strict Bun.deepEquals and expect().toStrictEqual(), matching jest/vitest. The C++ change is 8 lines in bindings.cpp:959-967: inside the existing isStrict && !skipPrototypeIdentity class-name check, and further gated by if constexpr (!checkPrototypes), it reads both [[Prototype]]s and returns false when exactly one is null. Two pre-existing comments describing the code as "prototype-blind" are reworded. Two docs pages get a one-line addition to the strict-mode difference list. An existing it.failing test is un-marked and extended, and comprehensive new tests are added in deep-equals.test.ts and expect.test.js.
Security risks
None. This is a comparison helper in the test runner / Bun namespace; no untrusted-input parsing, no auth/crypto/permissions.
Level of scrutiny
Medium. It's a behavioral change to a widely-used matcher, but it's a compat fix moving Bun toward the jest/vitest reference behavior, and the author ran the repo's toStrictEqual users to confirm nothing else relied on the old behavior. The native change follows the identical getPrototype + RETURN_IF_EXCEPTION pattern used 30 lines above at 822-825, so the exception-safety review reduces to pattern-matching. I traced each of the six entry points (deepEquals, jestDeepEquals, strictDeepEquals, jestStrictDeepEquals, Bun__deepEqualsNodeStrict, Bun__deepEqualsNodeStrictSkipProto) through the two nested if constexpr guards and confirmed only the two intended ones (<true,*,false,false>) reach the new code; the node path (checkPrototypes=true) and skipPrototype path (skipPrototypeIdentity=true) compile it out entirely.
Other factors
My earlier inline review flagged a redundant getPrototype call on the node entry point (double trap invocation); commit 9c27643 wrapped the new lookups in if constexpr (!checkPrototypes), which resolves it — the node path now compiles to exactly what it was before. The comment-cop bot's length complaints were addressed in 6afda93. All review threads are resolved. Test coverage is thorough: symmetric operand orders, nested-in-object, nested-in-array, two null-protos still deep-equal (and still differ on values / undefined props), Object.groupBy, Proxy pass-through and Proxy trap-override, and a regression guard on util.isDeepStrictEqual(_, _, true). No CODEOWNERS on bindings.cpp. The bug-hunting system found nothing.
Problem
expect(Object.assign(Object.create(null), { a: 1 })).toStrictEqual({ a: 1 })passes; jest and vitest fail it. Same for anObject.groupByresult against a literal, andBun.deepEquals(Object.create(null), {}, true)returns true."Object", so strict mode cannot tell them apart. Class instances are already caught by that check.node:assert.deepStrictEqualandutil.isDeepStrictEqualalready fail these since node compat batch: callback-throw dispatch, Assert class + native deep-equality parity, Intl gate + URL/buffer fallout, compile cache, watch kill-signal, profilers (+98 tests) #34660; this is thebun:test/Bun.deepEqualsside of the same gap.toEqualpasses them in every runner and keeps doing so.Fix
toMatchObject, the skipPrototype variants, and the node entry point (which already compared prototypes by identity before this point) behave as before, and two null-prototype objects still compare equal.Object.create(Object.create(null))) still equals{}here.toStrictEqualtest and the three new strictBun.deepEqualstests fail on Bun 1.4.0 and pass with this change; theexpecttest also passes under vitest 4.1.9. Docs gain the null-prototype example.Background
toStrictEqualandBun.deepEquals(a, b, true)share one C++ deep-equality routine, specialised by template flags. The same routine backsnode:assert.deepStrictEqual(prototype identity checked) and node's skipPrototype mode (every prototype check off).calculatedClassName(), which names an object after itsconstructorand falls back to"Object"when there is none, so{}andObject.create(null)look identical to it.a.constructor === b.constructorintoStrictEqual; a null-prototype object has noconstructor, so it never strictly equals a literal there. Arrays are exempt in both jest and this routine.getPrototypeOftrap, so the trap's answer decides, not the target's prototype.Object.groupBy,querystring.parse,parseArgs().values, and{ __proto__: null }literals.no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/bun-object/deep-equals.test.ts
Original description
Repro
Verified on Bun 1.4.0.
toEqualpasses all of these in every runner, and keeps doing so.Cause
toStrictEqualandBun.deepEquals(a, b, true)go throughBun__deepEquals<isStrict = true>insrc/jsc/bindings/bindings.cpp. The strict "same type" check comparesJSObject::calculatedClassName()of both operands. That returns"Object"both for an object inheriting fromObject.prototypeand for an object with no prototype at all (theconstructorlookup finds nothing and it falls back to the class info name), so a null-prototype object and an object literal pass as the same type. Class instances were already caught by this check; null-prototype objects are the one shape it cannot see.Jest and vitest fail these:
toStrictEqualruns thetypeEqualitytester, which comparesa.constructor === b.constructor, and a null-prototype object has noconstructor(undefined !== Object). Checked against vitest 4.1.9'sequals()with thetoStrictEqualtester list:node:assert.deepStrictEqual/util.isDeepStrictEqualalready fail these since #34660 (they compare prototypes by identity on their own entry point); this is thebun:test/Bun.deepEqualsside of the same gap. Bun's ownutil.inspectalso already treats the two shapes as different types ([Object: null prototype] {}vs{}), and the repo'sparseArgstests write{ __proto__: null }on the expected side oftoStrictEqualprecisely because they expect this to be checked.Fix
In the
isStrict && !skipPrototypeIdentityblock, after the class names match, read both[[Prototype]]s and return false when exactly one of them is null. This is the fixing hunk:Why this shape:
toEqual,Bun.deepEquals(a, b)), asymmetric matchers,toMatchObjectand theskipPrototypevariants (Bun.deepEquals(a, b, true, true),util.isDeepStrictEqual(a, b, true)) are untouched. The node entry point (checkPrototypes) already compared the prototypes by identity before reaching this block, so the new lookups are compiled out there and it keeps invoking a Proxy'sgetPrototypeOftrap once per side, as before.node:vm) still compare the way they did. Null vs non-null is the one distinctioncalculatedClassNamecollapses that shows up in practice (Object.create(null),{ __proto__: null },Object.groupBy,querystring.parse,parseArgs().values, node stylefs.promisesresults).getPrototype(globalObject)is the same call the node entry point uses a few lines up (and the same thingutil.inspectconsults when it prints[Object: null prototype]). For ordinary objects it is a type-info bit test plus a load. For a Proxy it is thegetPrototypeOftrap result, which only matters Proxy vs Proxy, since a Proxy already fails the class name check against a non-Proxy. That coincides with jest unless a trap contradicts its target (jest reads.constructorthrough thegettrap instead); the tests pin the trap-result behaviour so the choice is explicit. Both new calls haveRETURN_IF_EXCEPTION; the file was also run underBUN_JSC_validateExceptionChecks=1.typeEquality.The surrounding comments that described
Bun.deepEquals/expect()as prototype-blind are updated, and the null-prototype case is added to the strict mode list in theBun.deepEqualsdocs next to the class instance example.One remaining divergence, left alone as it does not come up in practice: an object whose prototype chain ends in a null-prototype object without ever defining
constructor(Object.create(Object.create(null))) still compares equal to{}here, while jest rejects it.Tests
test/js/bun/test/expect.test.js: newtoStrictEqual()test covering both operand orders, nesting in an object property and an array element, two null-prototype objects still being strictly equal (and still differing on values /undefinedproperties),Object.groupBy, andtoEqualcontinuing to pass for all of it. This file also runs under jest and vitest; the new test passes under vitest 4.1.9.test/js/bun/bun-object/deep-equals.test.ts: the existingit.failingforBun.deepEquals(Object.create(null), {}, true)now passes and is un-marked and extended; new tests for two null-prototype objects, for Proxies (a Proxy around a null-prototype target, and traps that report a different prototype than their target, which is what distinguishes reading the trap from unwrapping the target), and a guard thatutil.isDeepStrictEqual(a, b, true)(node's skipPrototype mode, which instantiates the same function withskipPrototypeIdentity) still treats a null-prototype object and a literal as equal, as node does.On Bun 1.4.0 exactly the new
toStrictEqualtest and the three newBun.deepEqualsstrict mode tests fail; with this change both files pass (416 and 49 tests). Also rantest/js/node/assert/(406 pass),test/js/node/util/parse_args/(alltoStrictEqualuses there already spell out__proto__: null; the only failure is a pre-existing 1000xBun.gc()stress test timing out on the debug build) and the other test files in the repo that usetoStrictEqual; the only failures there were debug build timeouts and container networking limits, none involving equality, so nothing else relied on the old behaviour.#32872 contains this same check as one of four unrelated fixes, but it predates #34660 and no longer applies to main (conflicts in
bindings.cppand two test files), so this is the null-prototype part on its own.